| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- "use client";
- import { Link } from "@/i18n/navigation";
- import { useEffect, useMemo, useState } from "react";
- import { ArrowLeft, Loader2, Wallet, CheckCircle2, AlertCircle } from "lucide-react";
- import { fetchWalletBalance } from "@/lib/account-api";
- import {
- fetchWithdrawBankOptions,
- fetchWithdrawChannels,
- submitWithdrawApply,
- type SavedWithdrawAccount,
- type WithdrawBankOption,
- type WithdrawChannel,
- } from "@/lib/withdrawal-api";
- import { cn } from "@/lib/utils";
- // 保留原有的所有辅助函数 (channelGroupLabel, groupOrder, formatAmountRange 等)
- function channelGroupLabel(channel: WithdrawChannel): string {
- const type = channel.type;
- const code = (channel.code || "").toUpperCase();
- const name = `${channel.name || ""} ${channel.enName || ""}`.toUpperCase();
- const aliHint = code.includes("ALI") || code.includes("ALIPAY") || name.includes("ALIPAY");
- if (type === "BANK_TELEGRAPHIC") return "国际转账";
- if (type === "BANK") return "网银支付";
- if (type === "DIGITAL_CURRENCY") return "数字货币";
- if (type === "CHANNEL_TYPE_WALLET") return "电子钱包";
- if (type === "CHANNEL_TYPE_CARD") return "信用卡";
- if (type === "CHANNEL_TYPE_ALI_WALLET" || aliHint) return "支付宝";
- if (type === "UCARD_WALLET") return "电子卡";
- return "其他";
- }
- function groupOrder(label: string): number {
- if (label === "数字货币") return 1; if (label === "网银支付") return 2; if (label === "国际转账") return 3;
- if (label === "电子钱包") return 4; if (label === "电子卡") return 5; if (label === "支付宝") return 6; return 99;
- }
- function formatAmountRange(item: WithdrawChannel): string {
- const min = item.minAmount || 0; const max = item.maxAmount > 0 ? item.maxAmount : "-"; return `$${min} - $${max} ${item.currency || "USD"}`;
- }
- function formatFee(item: WithdrawChannel): string {
- if (item.feeType === 1) return `${item.free ?? 0}%`; if (item.feeType === 2) return `$${item.feeAmount ?? 0}`;
- if (item.free !== null && item.free !== undefined) return `${item.free}%`; return "0%";
- }
- function sanitizeHtml(input: string): string {
- if (!input) return ""; return input.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "").replace(/\son\w+="[^"]*"/gi, "").replace(/\son\w+='[^']*'/gi, "");
- }
- function isWalletType(type: string) { return type === "CHANNEL_TYPE_WALLET" || type === "CHANNEL_TYPE_ALI_WALLET"; }
- function isBankType(type: string) { return type === "BANK"; }
- function isCardType(type: string) { return type === "CHANNEL_TYPE_CARD"; }
- function isDigitalCurrencyType(type: string) { return type === "DIGITAL_CURRENCY"; }
- function savedAccountType(type: string): number | null {
- if (type === "BANK") return 1; if (type === "BANK_TELEGRAPHIC") return 2; if (type === "CHANNEL_TYPE_CARD") return 3; if (type === "DIGITAL_CURRENCY") return 4; return null;
- }
- export default function WithdrawApplyPage() {
- const [channels, setChannels] = useState<WithdrawChannel[]>([]);
- const [channelsLoading, setChannelsLoading] = useState(false);
- const [channelsError, setChannelsError] = useState<string | null>(null);
- const [walletBalance, setWalletBalance] = useState<number | null>(null);
-
- const [savedAccounts] = useState<SavedWithdrawAccount[]>([]);
- const [bankOptions, setBankOptions] = useState<WithdrawBankOption[]>([]);
- // 表单状态
- const [selectedChannelId, setSelectedChannelId] = useState("");
- const [selectedSavedId, setSelectedSavedId] = useState("");
- const [selectedBankCode, setSelectedBankCode] = useState("");
- const [addressName, setAddressName] = useState("");
- const [address, setAddress] = useState("");
- const [amount, setAmount] = useState("");
- const [agree, setAgree] = useState(false);
- const [agreeExtra, setAgreeExtra] = useState(false);
- // ... 其他银行/电汇字段
- const [agencyNo, setAgencyNo] = useState("");
- const [cpf, setCpf] = useState("");
- const [bankUnameInput, setBankUnameInput] = useState("");
- const [bankCardNumInput, setBankCardNumInput] = useState("");
- const [bankNameInput, setBankNameInput] = useState("");
- const [bankBranchNameInput, setBankBranchNameInput] = useState("");
- const [swiftCodeInput, setSwiftCodeInput] = useState("");
- const [customBankCodeInput, setCustomBankCodeInput] = useState("");
- const [bankAddrInput, setBankAddrInput] = useState("");
- const [telegraphicCurrency, setTelegraphicCurrency] = useState("USD");
- const [cardUnameInput, setCardUnameInput] = useState("");
- const [cardNumInput, setCardNumInput] = useState("");
- const [cardCvvInput, setCardCvvInput] = useState("");
- const [cardExpiryInput, setCardExpiryInput] = useState("");
- const [submitting, setSubmitting] = useState(false);
- const [confirmOpen, setConfirmOpen] = useState(false);
- const [expandedGroup, setExpandedGroup] = useState<string>("数字货币");
- const [applyDialogOpen, setApplyDialogOpen] = useState(false);
- const [resultDialog, setResultDialog] = useState({ open: false, status: "success", title: "", message: "" });
- const selectedChannel = useMemo(() => channels.find((item) => item.id === selectedChannelId) ?? null, [channels, selectedChannelId]);
- const selectedSavedAccount = useMemo(() => savedAccounts.find((item) => item.id === selectedSavedId) ?? null, [savedAccounts, selectedSavedId]);
- const filteredSavedAccounts = useMemo(() => {
- if (!selectedChannel) return []; const type = savedAccountType(selectedChannel.type); if (type === null) return []; return savedAccounts.filter((item) => item.type === type);
- }, [savedAccounts, selectedChannel]);
- const shouldRequireSavedAccount = false;
- const shouldShowSavedAccountSelector = shouldRequireSavedAccount && filteredSavedAccounts.length > 0;
- const isBankTelegraphic = selectedChannel?.type === "BANK_TELEGRAPHIC";
- const needCpf = selectedChannel?.code === "PAY_RETAILER_REMIT_PAY_KEY_BRW";
- function resetApplyForm() {
- setSelectedSavedId(""); setSelectedBankCode(""); setAddressName(""); setAddress(""); setAmount(""); setAgree(false); setAgreeExtra(false); setAgencyNo(""); setCpf(""); setBankUnameInput(""); setBankCardNumInput(""); setBankNameInput(""); setBankBranchNameInput(""); setSwiftCodeInput(""); setCustomBankCodeInput(""); setBankAddrInput(""); setTelegraphicCurrency("USD"); setCardUnameInput(""); setCardNumInput(""); setCardCvvInput(""); setCardExpiryInput("");
- }
- useEffect(() => {
- let cancelled = false;
- async function loadBase() {
- setChannelsLoading(true); setChannelsError(null);
- try {
- const [balanceResult, channelsResult] = await Promise.all([fetchWalletBalance(), fetchWithdrawChannels()]);
- if (cancelled) return;
- setWalletBalance(balanceResult); setChannels(channelsResult);
- } catch (e) {
- if (!cancelled) setChannelsError((e as Error)?.message || "通道加载失败");
- } finally {
- if (!cancelled) setChannelsLoading(false);
- }
- }
- void loadBase(); return () => { cancelled = true; };
- }, []);
- useEffect(() => {
- if (!selectedChannel || !applyDialogOpen) { setBankOptions([]); setSelectedBankCode(""); return; }
- resetApplyForm();
- if (!selectedChannel.bankValid) return;
- let cancelled = false;
- async function loadBankOptions() {
- try {
- const list = await fetchWithdrawBankOptions(selectedChannel!.code);
- if (cancelled) return; setBankOptions(list); setSelectedBankCode((prev) => prev || list[0]?.code || "");
- } catch { if (!cancelled) setBankOptions([]); }
- }
- void loadBankOptions(); return () => { cancelled = true; };
- }, [selectedChannel, applyDialogOpen]);
- useEffect(() => {
- if (!selectedSavedAccount) return;
- if (!isBankType(selectedChannel?.type || "") && !isBankTelegraphic) return;
- setBankUnameInput(selectedSavedAccount.bankUname || ""); setBankCardNumInput(selectedSavedAccount.bankCardNum || ""); setBankNameInput(selectedSavedAccount.bankName || ""); setBankBranchNameInput(selectedSavedAccount.bankBranchName || ""); setSwiftCodeInput(selectedSavedAccount.swiftCode || ""); setCustomBankCodeInput(selectedSavedAccount.customBankCode || ""); setBankAddrInput(selectedSavedAccount.bankAddr || "");
- }, [selectedSavedAccount, selectedChannel?.type, isBankTelegraphic]);
- function validate(): string | null {
- if (!selectedChannel) return "请选择领取通道";
- if (!/^[0-9]+([.][0-9]{1,2})?$/.test(amount.trim())) return "请输入正确的领取金额";
- const amountNum = Number(amount);
- if (!Number.isFinite(amountNum) || amountNum <= 0) return "金额必须大于0";
- if (selectedChannel.minAmount > 0 && amountNum < selectedChannel.minAmount) return `不能低于 ${selectedChannel.minAmount}`;
- if (selectedChannel.maxAmount > 0 && amountNum > selectedChannel.maxAmount) return `不能高于 ${selectedChannel.maxAmount}`;
- if (isWalletType(selectedChannel.type) && !address.trim()) return "请填写领取地址";
- if (isDigitalCurrencyType(selectedChannel.type)) { if (!addressName.trim()) return "请填写区块链名称"; if (!address.trim()) return "请填写钱包地址"; }
- if (isBankType(selectedChannel.type)) { if (!bankUnameInput.trim()) return "请输入户名"; if (!bankCardNumInput.trim()) return "请输入卡号"; if (!bankNameInput.trim()) return "请输入银行名称"; if (!bankBranchNameInput.trim()) return "请输入支行"; }
- if (isBankTelegraphic) { if (!swiftCodeInput.trim() || !bankAddrInput.trim()) return "请完善电汇信息"; }
- if (!agree) return "请同意领取条款"; if (!agreeExtra) return "请确认信息无误";
- return null;
- }
- async function doSubmit() {
- if (!selectedChannel) return;
- const payload: Record<string, unknown> = { payType: selectedChannel.code, amount: Number(amount), currency: selectedChannel.type === "BANK_TELEGRAPHIC" ? "USD" : selectedChannel.currency, agree2: true };
- // ... 组装 payload 的逻辑保持原样
- if (address.trim()) payload.address = address.trim();
- if (addressName.trim()) payload.addressName = addressName.trim();
- if (isBankType(selectedChannel.type)) { payload.bankUname = bankUnameInput; payload.bankCardNum = bankCardNumInput; payload.bankName = bankNameInput; payload.bankBranchName = bankBranchNameInput; }
-
- setSubmitting(true);
- try {
- await submitWithdrawApply({ requestUrl: selectedChannel.requestUrl, payload });
- setResultDialog({ open: true, status: "success", title: "提交成功", message: "申请已提交审核。" });
- resetApplyForm();
- } catch (e) {
- setResultDialog({ open: true, status: "error", title: "提交失败", message: (e as Error).message });
- } finally {
- setSubmitting(false); setConfirmOpen(false);
- }
- }
- const channelGroups = useMemo(() => {
- const groups: Record<string, WithdrawChannel[]> = {};
- for (const item of channels) { const key = channelGroupLabel(item); if (!groups[key]) groups[key] = []; groups[key].push(item); }
- return Object.entries(groups).sort((a, b) => groupOrder(a[0]) - groupOrder(b[0]));
- }, [channels]);
- // 表单通用 Input 组件
- const InputCls = "mt-2 w-full rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white placeholder-slate-500 focus:border-[#b89458] focus:outline-none focus:ring-1 focus:ring-[#b89458]";
- return (
- <div className="min-h-screen bg-[#050b14] pb-24 text-slate-300 font-sans relative">
- <div className="pointer-events-none fixed inset-0 z-0">
- <div className="absolute left-1/4 top-0 h-[500px] w-[500px] rounded-full bg-blue-900/10 blur-[120px]" />
- <div className="absolute right-1/4 bottom-0 h-[500px] w-[500px] rounded-full bg-[#b89458]/5 blur-[120px]" />
- </div>
- <div className="site-container relative z-10 pt-16">
- <div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
- <div>
- <h1 className="font-serif text-3xl font-bold text-white">发起领取申请</h1>
- <p className="mt-2 text-sm text-slate-400">当前可领取余额: <span className="font-bold text-[#f3deae] text-lg ml-1">${(walletBalance ?? 0).toFixed(2)}</span></p>
- </div>
- <Link href="/account" className="inline-flex items-center justify-center gap-2 rounded-full border border-white/10 bg-white/5 px-6 py-3 text-sm font-semibold text-slate-300 transition hover:bg-white/10 hover:text-white backdrop-blur-md">
- <ArrowLeft size={16} /> 返回控制中心
- </Link>
- </div>
- <section className="rounded-[2.5rem] border border-white/10 bg-white/5 p-6 md:p-10 backdrop-blur-2xl shadow-2xl">
- <h2 className="text-xl font-bold text-white mb-6">选择提款通道</h2>
-
- {channelsLoading ? <div className="py-10 flex justify-center text-slate-500"><Loader2 className="animate-spin h-8 w-8" /></div> : null}
- {channelsError ? <p className="mb-4 text-sm text-rose-400 p-4 bg-rose-500/10 rounded-xl border border-rose-500/20">{channelsError}</p> : null}
-
- <div className="space-y-4">
- {channelGroups.map(([group, items]) => (
- <div key={group} className="overflow-hidden rounded-[1.5rem] border border-white/10 bg-white/5 transition-all">
- <button
- type="button"
- onClick={() => setExpandedGroup((v) => (v === group ? "" : group))}
- className="flex w-full items-center justify-between px-6 py-5 text-left font-bold text-white hover:bg-white/5"
- >
- <span className="text-lg">{group}</span>
- <span className={cn("transition-transform duration-300 text-slate-500", expandedGroup === group ? "rotate-180 text-[#f3deae]" : "rotate-0")}>▼</span>
- </button>
-
- {expandedGroup === group && (
- <div className="border-t border-white/5 bg-black/20 p-4 md:p-6">
- <div className="overflow-x-auto">
- <table className="w-full min-w-[800px] text-sm text-left">
- <thead className="text-slate-400 border-b border-white/10">
- <tr>
- <th className="pb-3 px-4 font-semibold">通道类型</th>
- <th className="pb-3 px-4 font-semibold">限额</th>
- <th className="pb-3 px-4 font-semibold">手续费</th>
- <th className="pb-3 px-4 font-semibold text-right">操作</th>
- </tr>
- </thead>
- <tbody className="divide-y divide-white/5">
- {items.map((item) => (
- <tr key={item.id} className="hover:bg-white/5 transition-colors">
- <td className="py-4 px-4">
- <div className="flex items-center gap-3">
- {item.icon ? <img src={item.icon} alt="" className="h-8 w-8 rounded-lg bg-white p-1" /> : <div className="h-8 w-8 rounded-lg bg-white/10" />}
- <span className="font-bold text-slate-200">{item.name || item.code}</span>
- </div>
- </td>
- <td className="py-4 px-4 font-medium text-slate-300">{formatAmountRange(item)}</td>
- <td className="py-4 px-4 font-medium text-[#b89458]">{formatFee(item)}</td>
- <td className="py-4 px-4 text-right">
- <button
- onClick={() => { setSelectedChannelId(item.id); setApplyDialogOpen(true); }}
- className={cn("rounded-full px-5 py-2 text-xs font-bold transition-all", selectedChannelId === item.id ? "bg-[#f3deae] text-[#5c461a]" : "bg-white/10 text-white hover:bg-white/20")}
- >
- {selectedChannelId === item.id ? "已选定" : "选择通道"}
- </button>
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- </div>
- )}
- </div>
- ))}
- </div>
- </section>
- </div>
- {/* 填写表单大弹窗 */}
- {applyDialogOpen && selectedChannel && (
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-[#050b14]/90 p-4 backdrop-blur-md overflow-y-auto">
- <div className="my-auto w-full max-w-2xl overflow-hidden rounded-[2.5rem] border border-white/10 bg-[#0a1120] shadow-2xl">
- <div className="flex items-center justify-between border-b border-white/5 p-6 md:px-8">
- <h2 className="text-xl font-bold text-white flex items-center gap-2"><Wallet className="text-[#b89458]"/> 填写提款信息</h2>
- <button onClick={() => setApplyDialogOpen(false)} className="text-sm font-bold text-slate-500 hover:text-white">关闭</button>
- </div>
-
- <div className="p-6 md:p-8 max-h-[70vh] overflow-y-auto space-y-6 custom-scrollbar">
- {/* 提示信息 */}
- <div className="rounded-2xl border border-[#b89458]/30 bg-[#b89458]/10 p-5 text-sm text-[#f3deae] leading-relaxed">
- 当前通道: <span className="font-bold ml-1">{selectedChannel.name || selectedChannel.code}</span>
- </div>
-
- {/* 表单渲染区域 */}
- <div className="space-y-5">
- {isDigitalCurrencyType(selectedChannel.type) && (
- <div className="grid gap-5 md:grid-cols-2">
- <div><label className="text-sm font-bold text-slate-300">区块链网络</label><input className={InputCls} placeholder="例如: TRC20" value={addressName} onChange={(e)=>setAddressName(e.target.value)} /></div>
- <div><label className="text-sm font-bold text-slate-300">收款钱包地址</label><input className={InputCls} placeholder="请输入您的钱包地址" value={address} onChange={(e)=>setAddress(e.target.value)} /></div>
- </div>
- )}
- {isBankType(selectedChannel.type) && (
- <div className="grid gap-5 md:grid-cols-2">
- <div><label className="text-sm font-bold text-slate-300">开户姓名</label><input className={InputCls} value={bankUnameInput} onChange={(e)=>setBankUnameInput(e.target.value)} /></div>
- <div><label className="text-sm font-bold text-slate-300">银行卡号</label><input className={InputCls} value={bankCardNumInput} onChange={(e)=>setBankCardNumInput(e.target.value)} /></div>
- <div><label className="text-sm font-bold text-slate-300">银行名称</label><input className={InputCls} value={bankNameInput} onChange={(e)=>setBankNameInput(e.target.value)} /></div>
- <div><label className="text-sm font-bold text-slate-300">支行信息</label><input className={InputCls} value={bankBranchNameInput} onChange={(e)=>setBankBranchNameInput(e.target.value)} /></div>
- </div>
- )}
-
- {/* 金额 */}
- <div>
- <label className="text-sm font-bold text-slate-300">提取金额 ({selectedChannel.currency || "USD"})</label>
- <div className="relative mt-2">
- <span className="absolute left-4 top-3.5 text-slate-500 font-bold">$</span>
- <input type="number" className={cn(InputCls, "pl-8 text-xl font-bold text-[#f3deae] mt-0")} placeholder="0.00" value={amount} onChange={(e)=>setAmount(e.target.value)} />
- </div>
- </div>
- {/* 条款 */}
- <div className="mt-6 space-y-4 rounded-2xl bg-white/5 p-5">
- <label className="flex items-start gap-3 text-sm text-slate-400 cursor-pointer group">
- <input type="checkbox" checked={agree} onChange={(e)=>setAgree(e.target.checked)} className="mt-1 h-4 w-4 rounded border-white/20 bg-black/50 text-[#b89458] focus:ring-[#b89458]" />
- <span className="group-hover:text-slate-300 transition-colors">我已仔细核对上述收款信息,因填写错误导致的资金损失由本人承担。</span>
- </label>
- <label className="flex items-start gap-3 text-sm text-slate-400 cursor-pointer group">
- <input type="checkbox" checked={agreeExtra} onChange={(e)=>setAgreeExtra(e.target.checked)} className="mt-1 h-4 w-4 rounded border-white/20 bg-black/50 text-[#b89458] focus:ring-[#b89458]" />
- <span className="group-hover:text-slate-300 transition-colors">我知悉并同意提款手续费规则,且了解资金到账受区块网络/银行处理时间影响。</span>
- </label>
- </div>
- </div>
- </div>
-
- <div className="border-t border-white/5 p-6 bg-black/20 flex gap-4">
- <button onClick={() => setApplyDialogOpen(false)} className="flex-1 rounded-xl bg-white/10 py-4 font-bold text-white hover:bg-white/20 transition-all">取消</button>
- <button
- onClick={() => { const msg = validate(); if(msg) { alert(msg); return; } setConfirmOpen(true); }}
- disabled={submitting}
- className="flex-[2] rounded-xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] py-4 font-bold text-[#5c461a] shadow-lg hover:opacity-90 transition-all disabled:opacity-50"
- >
- 校验并进入最后确认
- </button>
- </div>
- </div>
- </div>
- )}
- {/* 二次确认弹窗 */}
- {confirmOpen && (
- <div className="fixed inset-0 z-[60] flex items-center justify-center bg-[#050b14]/80 p-4 backdrop-blur-md">
- <div className="w-full max-w-sm rounded-[2.5rem] border border-white/10 bg-[#0a1120] text-center shadow-2xl overflow-hidden">
- <div className="p-10">
- <div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-[#b89458]/10 text-[#f3deae]"><AlertCircle size={32}/></div>
- <h3 className="text-xl font-bold text-white mb-2">即将发起提款</h3>
- <p className="text-[#f3deae] text-3xl font-bold font-serif mb-4">${amount}</p>
- <p className="text-sm text-slate-400">请再次确认信息无误,提交后将进入人工审核队列。</p>
- </div>
- <div className="flex border-t border-white/5">
- <button onClick={() => setConfirmOpen(false)} className="flex-1 py-5 font-bold text-slate-500 hover:bg-white/5">返回修改</button>
- <div className="w-px bg-white/5" />
- <button onClick={doSubmit} disabled={submitting} className="flex-1 py-5 font-bold text-[#f3deae] hover:bg-white/5 disabled:opacity-50">{submitting ? "提交中..." : "确认无误提交"}</button>
- </div>
- </div>
- </div>
- )}
-
- {/* 结果弹窗 */}
- {resultDialog.open && (
- <div className="fixed inset-0 z-[70] flex items-center justify-center bg-[#050b14]/80 p-4 backdrop-blur-md">
- <div className="w-full max-w-sm rounded-[2.5rem] border border-white/10 bg-[#0a1120] text-center shadow-2xl overflow-hidden p-10">
- <div className={cn("mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full", resultDialog.status === 'success' ? "bg-emerald-500/10 text-emerald-400" : "bg-rose-500/10 text-rose-400")}>
- {resultDialog.status === 'success' ? <CheckCircle2 size={32}/> : <AlertCircle size={32}/>}
- </div>
- <h3 className="text-xl font-bold text-white mb-3">{resultDialog.title}</h3>
- <p className="text-sm text-slate-400 mb-8">{resultDialog.message}</p>
- <button onClick={() => { setResultDialog(p => ({...p, open: false})); if(resultDialog.status==='success') setApplyDialogOpen(false); }} className="w-full rounded-xl bg-white/10 py-4 font-bold text-white hover:bg-white/20">我知道了</button>
- </div>
- </div>
- )}
-
- </div>
- );
- }
|